Skip to content

feat: Add Mattermost channel support#428

Open
unverbraucht wants to merge 20 commits intospacedriveapp:mainfrom
unverbraucht:feat/mattermost
Open

feat: Add Mattermost channel support#428
unverbraucht wants to merge 20 commits intospacedriveapp:mainfrom
unverbraucht:feat/mattermost

Conversation

@unverbraucht
Copy link

@unverbraucht unverbraucht commented Mar 13, 2026

Summary

  • Full Mattermost WebSocket + HTTP messaging adapter (src/messaging/mattermost.rs)
  • Configuration UI: Mattermost appears in the messaging platforms panel; instances configured in config.toml show up automatically
  • Named adapter instances supported (multiple Mattermost servers in parallel)

What's included

Adapter (src/messaging/mattermost.rs)

  • Connects via personal access token, receives events over WebSocket
  • Posts are double-encoded in the WebSocket data.post field — parsed correctly
  • Streaming: creates a post with a zero-width space placeholder, then throttled 500 ms edits, flushed on StreamEnd
  • Conversation IDs: mattermost:{team_id}:{channel_id} for channels, mattermost:{team_id}:dm:{user_id} for DMs
  • Fail-closed permission filtering: team filter and per-team channel filter; rejects messages when team_id is absent but filters are configured
  • Sets metadata_keys::MESSAGE_ID on inbound messages for reply threading
  • Hot-reload via Arc<ArcSwap<MattermostPermissions>> in the file watcher

Config (src/config/)

  • MattermostConfig / MattermostInstanceConfig with token-redacting Debug impls
  • MattermostPermissions with from_config, from_instance_config, from_bindings_for_adapter
  • [messaging.mattermost] TOML section: base_url, token, optional team_id, max_attachment_bytes
  • [[messaging.mattermost.instances]] for named instances
  • URL validated at config load time
  • team_id field on Binding for channel-level permission scoping

Settings UI (interface/)

  • Mattermost listed in the platform catalog with a server icon
  • "Server URL" + "Access Token" credential inputs with validation
  • src/api/messaging.rs: reads [messaging.mattermost] into the instances list; writes credentials to TOML via the create-instance endpoint

Config example

[messaging.mattermost]
enabled = true
base_url = "https://mattermost.example.com"
token = "your-personal-access-token"
# team_id = "optional-default-team"

Test plan

  • Configure via config.toml — verify instance appears in Settings UI
  • Configure via Settings UI — verify credentials written correctly to config.toml
  • Send a message in a channel — verify bot responds
  • Send a DM — verify bot responds
  • Verify streaming responses edit the post in-place with ~500 ms throttle
  • Verify named instances ([[messaging.mattermost.instances]]) connect independently
  • Verify team filter rejects messages from other teams
  • Verify channel filter rejects messages from unlisted channels
  • Verify message with no team_id in the event is rejected when filters are configured (fail-closed)

I based this on the Slack integrations. Written with the help of both Claude and Gemini. Would appreciate review and test feedback.

Note

Introduces full Mattermost messaging adapter with WebSocket event handling, TOML-based configuration, and hot-reloadable permission filtering. Spans 19 files across adapter implementation, config structures, and UI integration (+3400 lines). Follows existing Slack adapter patterns for consistency.

Written by Tembo for commit b069b56.

Implements a full Mattermost adapter using a custom HTTP + WebSocket
client (reqwest + tokio-tungstenite), following the existing adapter
architecture.

Features:
- WebSocket-based inbound messages with exponential backoff reconnection
- Text responses, streaming edits (ZWS placeholder + throttled PUT),
  file uploads, reactions, typing indicators, thread replies
- History fetch via /channels/{id}/posts
- Named adapter instances with per-instance permissions
- Hot-reload of permissions via spawn_file_watcher
- Conversation IDs: mattermost:{team_id}:{channel_id} and
  mattermost:{team_id}:dm:{user_id}
- Fail-closed permission filtering: messages without a team_id are
  rejected when team/channel filters are configured
- URL validation at config load time

Config:
  [messaging.mattermost]
  enabled = true
  base_url = "https://mattermost.example.com"
  token = "env:MATTERMOST_TOKEN"
  team_id = "optional-default-team-id"
Resolved conflicts in src/config.rs (refactored to submodules), src/main.rs,
src/messaging.rs, and src/messaging/target.rs. Distributed Mattermost config
additions across the new config submodule structure introduced upstream.
- Add Mattermost platform to ChannelSettingCard, ChannelEditModal,
  Settings, and platformIcons
- Server URL and access token credential inputs
- Backend: read mattermost config into instances list in messaging_status
- Backend: write base_url and token to TOML via create_messaging_instance
- Mattermost instances configured manually in config.toml appear in the UI
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds first-class Mattermost support: docs, frontend inputs and API fields, TOML schema and loader, new config types and permissions, binding.team_id, secrets registration, hot-reload watcher/main wiring, broadcast target normalization, and a full Rust Mattermost messaging adapter with tests.

Changes

Cohort / File(s) Summary
Design & docs
docs/mattermost.md
New Mattermost integration documentation covering features, setup, multi-instance config, bindings, and ID discovery.
Frontend — platform UI & API
interface/src/components/ChannelEditModal.tsx, interface/src/routes/Settings.tsx, interface/src/lib/platformIcons.tsx, interface/src/components/ChannelSettingCard.tsx, interface/src/api/client.ts
Adds "mattermost" platform to UI types and icons; adds Mattermost server URL and token inputs/validation; exposes mattermost_base_url/mattermost_token in messaging instance API payloads.
Config TOML schema & loader
src/config/toml_schema.rs, src/config/load.rs
Adds TomlMattermostConfig/TomlMattermostInstanceConfig, default max-attachment, env/instance credential resolution, warnings for missing creds, and per-instance Mattermost config wiring.
Runtime config types, validation & permissions
src/config/types.rs, src/config/permissions.rs, src/config.rs
Introduces MattermostConfig/MattermostInstanceConfig types, validate_mattermost_url helper, SystemSecrets impls, MattermostPermissions implementation, and adds Binding.team_id.
Watcher, initialization & main wiring
src/config/watcher.rs, src/main.rs
Adds hot-reloadable Mattermost permissions observable; threads mattermost_permissions through initialize_agents and spawn_file_watcher; hot-starts default and named Mattermost adapters on config reload.
Messaging crate & adapter
src/messaging.rs, src/messaging/mattermost.rs
Registers pub mod mattermost; adds a complete MattermostAdapter implementation with constructor, Messaging trait methods, extensive HTTP/WS integration, helpers, and tests (~1796 LOC).
Messaging API & target resolution
src/messaging/target.rs, src/api/messaging.rs
Adds Mattermost target parsing/normalization and adapter extraction; extends InstanceCredentials with mattermost_base_url/mattermost_token; wires Mattermost into messaging_status, create/delete instance, and platform toggle flows.
API bindings & secrets
src/api/bindings.rs, src/secrets/store.rs
Adds team_id to binding API types and create/update/delete flows; includes team_id in list responses; registers MattermostConfig secret fields in the system secret registry.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • jamiepine
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: Add Mattermost channel support' accurately summarizes the main change: adding a new Mattermost messaging adapter with configuration UI and named instance support.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, covering the adapter implementation, configuration structure, UI changes, test plan, and noting it spans 19 files with ~3400 lines.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Empty dm_allowed_users blocks all DMs (fail-closed). Non-empty list
allows only the specified Mattermost user IDs, consistent with Slack
and Telegram adapter behaviour.

All other layers (config, TOML schema, permissions merge from bindings,
bindings API, UI TagInput) were already in place.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/config/types.rs (1)

1543-1612: ⚠️ Potential issue | 🔴 Critical

Channel-scoped Mattermost bindings never match.

At Line 1543 the generic channel_ids gate only checks Discord/Slack/Twitch metadata. For a Mattermost binding with channel_ids, it returns false before the Mattermost-specific branch at Line 1604 executes, so channel-scoped Mattermost bindings are effectively dead.

🎯 Proposed fix
-        if !self.channel_ids.is_empty() {
+        if !self.channel_ids.is_empty() && self.channel != "mattermost" {
             let message_channel = message
                 .metadata
                 .get("discord_channel_id")
                 .and_then(|v| v.as_u64())
                 .map(|v| v.to_string());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1543 - 1612, The generic channel_ids gate
(using message.metadata discord/slack/twitch) prevents Mattermost-specific
matching; change the logic so Mattermost bindings are handled by the
Mattermost-specific branch instead of being short-circuited. Concretely, update
the initial if !self.channel_ids.is_empty() block to skip the generic
Discord/Slack/Twitch checks when self.channel == "mattermost" (e.g. make the
condition if !self.channel_ids.is_empty() && self.channel != "mattermost") or
alternatively include message.metadata.get("mattermost_channel_id").and_then(|v|
v.as_str()) in the direct_match calculation; reference self.channel_ids,
self.channel, message.metadata and the later mattermost_channel_id check so the
Mattermost-specific branch (the block that reads "mattermost_channel_id" and the
one guarded by if self.channel == "mattermost") can correctly match.
🧹 Nitpick comments (1)
interface/src/components/ChannelSettingCard.tsx (1)

642-652: Add lightweight client-side URL validation for Mattermost server URL.

Line 643 currently only checks non-empty input. Validating URL format here gives faster, clearer feedback before submit.

Suggested patch
 		} else if (platform === "mattermost") {
-			if (!credentialInputs.mattermost_base_url?.trim()) {
+			const baseUrl = credentialInputs.mattermost_base_url?.trim();
+			if (!baseUrl) {
 				setMessage({text: "Server URL is required", type: "error"});
 				return;
 			}
+			try {
+				new URL(baseUrl);
+			} catch {
+				setMessage({text: "Server URL must be a valid absolute URL", type: "error"});
+				return;
+			}
 			if (!credentialInputs.mattermost_token?.trim()) {
 				setMessage({text: "Access token is required", type: "error"});
 				return;
 			}
-			credentials.mattermost_base_url = credentialInputs.mattermost_base_url.trim();
+			credentials.mattermost_base_url = baseUrl;
 			credentials.mattermost_token = credentialInputs.mattermost_token.trim();
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/ChannelSettingCard.tsx` around lines 642 - 652, In
the platform === "mattermost" branch of ChannelSettingCard, replace the simple
non-empty check for credentialInputs.mattermost_base_url with a lightweight
client-side URL validation: trim the input, attempt to validate that it is a
well-formed HTTP/HTTPS URL (for example with new URL(trimmed) and checking
protocol is http: or https:), and if invalid call setMessage({text: "Invalid
server URL", type: "error"}) and return; only after it passes validation assign
credentials.mattermost_base_url = trimmed and proceed; keep the existing token
trim/validation logic intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/messaging.rs`:
- Around line 1331-1337: The Mattermost branch writes
credentials.mattermost_base_url into platform_table before validating it, which
can corrupt config if Config::load_from_path() later fails; modify the logic in
the "mattermost" arm to first validate the provided URL (e.g., parse with
url::Url::parse or similar) and only set platform_table["base_url"] when
validation succeeds, and if you still call Config::load_from_path() ensure you
check its Result and rollback/remove the recently-inserted keys from
platform_table (or avoid mutating platform_table until validation passes) so a
bad URL never gets persisted; apply the same pattern to the other occurrences
around the other branches referenced (the sections around the previous
Config::load_from_path() calls).
- Around line 1520-1522: The match arm accepting "mattermost" currently does not
clear the default instance credentials, leaving base_url and token set after a
delete; update the default-instance cleanup code path (the match on platform and
the cleanup block around lines 1578-1619) so that when platform == "mattermost"
you explicitly clear the stored credentials (set base_url and token to
None/empty on the config/struct representing the default Mattermost adapter),
persist the updated configuration/state (or delete those fields in the DB), and
only return success after the changes are saved so the default adapter cannot
reappear on reload.

In `@src/config/load.rs`:
- Around line 2232-2255: The current use of filter_map on mm.instances drops
Mattermost instances with unresolved token/base_url before
validate_named_messaging_adapters() runs; instead iterate over mm.instances
(e.g., with map or map + collect) and always produce a MattermostInstanceConfig
record for each instance, but if token.is_none() || base_url.is_none() set
enabled: false (and keep the original instance.name, team_id, dm_allowed_users,
max_attachment_bytes), emit the existing tracing::warn with context, and
preserve the unresolved token/base_url values (or use empty strings if the
struct requires) so validate_named_messaging_adapters() and the settings UI
still see the named adapter present.

In `@src/config/watcher.rs`:
- Around line 245-251: The watcher currently recreates an ArcSwap for each named
Mattermost adapter and immediately drops it so updates never reach running
adapters; change this to keep a shared ArcSwap handle per adapter runtime key
and update that stored value on reload. Concretely: instead of creating a new
local ArcSwap in the adapter initialization, register its ArcSwap handle in a
central registry/map keyed by the adapter runtime key (the same key used to
identify "mattermost:{instance}"), and in the watcher code (where
mattermost_permissions and MattermostPermissions::from_config are used) iterate
the registry entries for mattermost adapters and call
.store(Arc::new(new_perms)) on each ArcSwap handle; ensure the adapter
construction uses the shared ArcSwap handle from the registry rather than
building and dropping its own.

In `@src/messaging/mattermost.rs`:
- Around line 532-540: StreamEnd currently calls edit_post with the full
active.accumulated_text which can exceed MAX_MESSAGE_LENGTH and cause the final
edit to fail; update the OutboundResponse::StreamEnd handling to finalize
streaming by chunking accumulated_text into slices no larger than
MAX_MESSAGE_LENGTH and apply them safely (e.g., edit_post the first chunk for
the placeholder/post_id via active.post_id and then create_post or append
additional posts for remaining chunks), using the existing MAX_MESSAGE_LENGTH
constant and the edit_post/create_post methods to ensure no single edit exceeds
the limit and to remove the placeholder correctly; reference
OutboundResponse::StreamEnd, active.accumulated_text, active.post_id, edit_post,
create_post and MAX_MESSAGE_LENGTH when implementing the split-and-send logic.
- Around line 821-894: When constructing inbound DMs, enforce
MattermostPermissions.dm_allowed_users and add the directed-message hints to
metadata: if post.channel_type indicates a DM ("D" or "G") check
permissions.dm_allowed_users (or per-team list) and return None if the sender
isn't allowed; also add metadata flags the binding layer expects (e.g., a
direct_message/dm_hint boolean and mention-reply-related fields derived from
permissions.require_mention and post root/mentions) into the same metadata
HashMap before creating the InboundMessage so require_mention and
dm_allowed_users routing work (update logic around post.channel_type,
permissions, metadata, and the InboundMessage construction).
- Around line 490-497: OutboundResponse::StreamStart only uses metadata
"mattermost_root_id" so streamed replies escape threads; change the root_id
resolution to mirror the text path: first try
message.metadata.get("mattermost_root_id").and_then(|v| v.as_str()), then fall
back to the REPLY_TO_MESSAGE_ID metadata (or constant) if the first is None, and
pass that resolved root_id into create_post(channel_id, "\u{200B}", root_id).
Update the root_id variable in the StreamStart branch (around
OutboundResponse::StreamStart and the create_post call) to use this fallback
logic so streamed placeholders remain in the original thread.
- Around line 150-181: start_typing() currently spawns a new background task and
inserts its JoinHandle into typing_tasks without stopping any previous task,
causing leaks; at the top of start_typing() call
self.stop_typing(channel_id).await to abort any existing task for that channel
before spawning a new one, and continue using the same
channel_id/channel_id_owned logic and TYPING_INDICATOR_INTERVAL. In shutdown(),
replace the write().await.clear() on typing_tasks with iterating over the map's
values and calling abort() on each JoinHandle (and optionally await or check
join results) to ensure all spawned tasks are terminated. Finally, replace the
discarded shutdown channel send (the current let _ = ...) with proper error
handling (log or return the error) so failures to notify shutdown are not
silently ignored.

In `@src/messaging/target.rs`:
- Around line 143-161: The Mattermost branch currently drops the
instance/runtime adapter key when parsing channel.id (the match arm under
"mattermost" that builds parts from channel.id), causing named instances like
mattermost:{instance}:... to lose their adapter; update that logic so when
platform_meta lacks mattermost_channel_id you parse parts and, for patterns
["mattermost", instance, "dm", user_id] return a conversation id that preserves
the instance (e.g. format!("{instance}:dm:{user_id}")), and for ["mattermost",
instance, channel_id] return format!("{instance}:{channel_id}") instead of only
the channel_id; keep using json_value_to_string and channel.platform_meta as
before and only fall back to None for non-matching shapes.
- Around line 261-269: normalize_mattermost_target currently returns the full
suffix after strip_repeated_prefix instead of the actual send target; update it
to parse the suffix by splitting on ':' and return the real channel or DM id: if
the stripped target is empty return None, if it contains ':' split into parts
and if the first part is "dm" return "dm:{user_id}" using the last part,
otherwise return the last part (the channel id), and if there is no ':' return
the target as-is; use the existing normalize_mattermost_target and
strip_repeated_prefix symbols to locate and modify the logic accordingly.

---

Outside diff comments:
In `@src/config/types.rs`:
- Around line 1543-1612: The generic channel_ids gate (using message.metadata
discord/slack/twitch) prevents Mattermost-specific matching; change the logic so
Mattermost bindings are handled by the Mattermost-specific branch instead of
being short-circuited. Concretely, update the initial if
!self.channel_ids.is_empty() block to skip the generic Discord/Slack/Twitch
checks when self.channel == "mattermost" (e.g. make the condition if
!self.channel_ids.is_empty() && self.channel != "mattermost") or alternatively
include message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str())
in the direct_match calculation; reference self.channel_ids, self.channel,
message.metadata and the later mattermost_channel_id check so the
Mattermost-specific branch (the block that reads "mattermost_channel_id" and the
one guarded by if self.channel == "mattermost") can correctly match.

---

Nitpick comments:
In `@interface/src/components/ChannelSettingCard.tsx`:
- Around line 642-652: In the platform === "mattermost" branch of
ChannelSettingCard, replace the simple non-empty check for
credentialInputs.mattermost_base_url with a lightweight client-side URL
validation: trim the input, attempt to validate that it is a well-formed
HTTP/HTTPS URL (for example with new URL(trimmed) and checking protocol is http:
or https:), and if invalid call setMessage({text: "Invalid server URL", type:
"error"}) and return; only after it passes validation assign
credentials.mattermost_base_url = trimmed and proceed; keep the existing token
trim/validation logic intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f94c442a-2f2b-48e9-82b0-bb38672e10ae

📥 Commits

Reviewing files that changed from the base of the PR and between ed3aebe and b069b56.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
  • Cargo.toml is excluded by !**/*.toml
📒 Files selected for processing (17)
  • docs/mattermost.md
  • interface/src/api/client.ts
  • interface/src/components/ChannelEditModal.tsx
  • interface/src/components/ChannelSettingCard.tsx
  • interface/src/lib/platformIcons.tsx
  • interface/src/routes/Settings.tsx
  • src/api/messaging.rs
  • src/config.rs
  • src/config/load.rs
  • src/config/permissions.rs
  • src/config/toml_schema.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/main.rs
  • src/messaging.rs
  • src/messaging/mattermost.rs
  • src/messaging/target.rs

SEC2: add sanitize_reaction_name() — resolves unicode emoji to shortcodes
via the emojis crate, strips colon wrappers, lowercases plain names.

FN1: user identity resolution — add user_identity_cache, resolve
display name from /users/{id} API on first encounter, set
sender_display_name metadata and formatted_author.

FN2: channel name resolution — add channel_name_cache, resolve
display_name from /channels/{id} API on first encounter, set
mattermost_channel_name and CHANNEL_NAME metadata.

FN3: fetch_history now returns timestamps parsed from post.create_at
milliseconds instead of always None.

FN4: bot mention detection — check message text for @bot_username,
set mattermost_mentions_or_replies_to_bot metadata flag so
require_mention routing mode works correctly.

Also adds 12 new unit tests covering these fixes.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/config/types.rs (2)

1651-1659: ⚠️ Potential issue | 🟠 Major

require_mention does not recognize Mattermost metadata.

Mattermost inbound metadata uses mattermost_mentions_or_replies_to_bot, but the mention-key switch has no mattermost arm, so bindings with require_mention = true will always drop Mattermost messages.

💡 Proposed fix
         let mention_key = match message.source.as_str() {
             "discord" => "discord_mentions_or_replies_to_bot",
             "slack" => "slack_mentions_or_replies_to_bot",
             "twitch" => "twitch_mentions_or_replies_to_bot",
             "telegram" => "telegram_mentions_or_replies_to_bot",
+            "mattermost" => "mattermost_mentions_or_replies_to_bot",
             // Unknown platforms: if require_mention is set, default to
             // requiring a mention (safe default).
             _ => return false,
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1651 - 1659, The match on message.source
(which sets mention_key) is missing a branch for Mattermost, causing
require_mention checks to drop Mattermost messages; update the match in the code
that defines mention_key (the match on message.source.as_str()) to include a
"mattermost" arm that maps to "mattermost_mentions_or_replies_to_bot" so
Mattermost inbound metadata is recognized the same as other platforms.

1543-1576: ⚠️ Potential issue | 🔴 Critical

Mattermost channel_ids bindings are currently blocked by the generic channel filter.

Line 1543 applies the generic Discord/Slack/Twitch channel check for all platforms; for Mattermost this returns false before the Mattermost branch at Line 1604 runs.

💡 Proposed fix
-        if !self.channel_ids.is_empty() {
+        if !self.channel_ids.is_empty() && self.channel != "mattermost" {
             let message_channel = message
                 .metadata
                 .get("discord_channel_id")
                 .and_then(|v| v.as_u64())
                 .map(|v| v.to_string());
@@
-        if !self.channel_ids.is_empty() && self.channel == "mattermost" {
+        if !self.channel_ids.is_empty() && self.channel == "mattermost" {
             let message_channel = message
                 .metadata
                 .get("mattermost_channel_id")
                 .and_then(|v| v.as_str());
             if !self.channel_ids.iter().any(|id| Some(id.as_str()) == message_channel) {
                 return false;
             }
         }

Also applies to: 1604-1612

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1543 - 1576, The generic channel filtering
block using self.channel_ids and the computed variables message_channel,
slack_channel, and twitch_channel is preventing Mattermost checks from running;
update the channel extraction and matching logic to also handle Mattermost by
reading message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str())
(and include a parent mattermost id if applicable) and include that value in the
direct_match boolean (similar to slack_channel/twitch_channel checks), and
ensure the same adjustment is applied to the later Mattermost-specific branch
referenced around the parent/channel checks (the code that computes
message_channel, parent_channel, direct_match, and parent_match).
♻️ Duplicate comments (8)
src/messaging/mattermost.rs (8)

518-523: ⚠️ Potential issue | 🟠 Major

StreamStart misses thread fallback metadata.

This path only uses mattermost_root_id; it should also fallback to REPLY_TO_MESSAGE_ID like the text path does.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 518 - 523,
OutboundResponse::StreamStart currently extracts only
message.metadata["mattermost_root_id"] and calls start_typing without the same
thread fallback used elsewhere; update the StreamStart branch to follow the
text-path logic by checking message.metadata for "mattermost_root_id" and, if
absent, falling back to REPLY_TO_MESSAGE_ID before using that value for threaded
behavior (the same metadata lookup pattern used in the text response branch),
ensuring start_typing/channel handling uses the resolved thread id from
message.metadata.

154-186: ⚠️ Potential issue | 🟠 Major

Typing tasks are not safely replaced/terminated.

Line 185 overwrites existing channel task handles without explicit abort, and Line 770 clears handles on shutdown without aborting them.

#!/bin/bash
# Verify typing task lifecycle handling in start_typing/shutdown
rg -n -C4 'async fn start_typing|typing_tasks.*insert|async fn shutdown|typing_tasks.*clear' src/messaging/mattermost.rs
💡 Proposed fix
     async fn start_typing(&self, channel_id: &str) {
+        self.stop_typing(channel_id).await;
         let Some(user_id) = self.bot_user_id.get().cloned() else {
             return;
         };
@@
-        self.typing_tasks.write().await.clear();
+        let mut typing_tasks = self.typing_tasks.write().await;
+        for (_, handle) in typing_tasks.drain() {
+            handle.abort();
+        }

Also applies to: 770-771

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 154 - 186, The start_typing
function currently inserts a new tokio::task::JoinHandle into typing_tasks
without stopping any existing task for the same channel; update start_typing to
check typing_tasks (by channel_id) for an existing handle, call abort() on it
and optionally await its completion or ignore the result before inserting the
new handle so you don't leak duplicate tasks; likewise modify the shutdown logic
(the code that currently clears typing_tasks) to instead iterate all stored
JoinHandles, call abort() on each handle (and await or detach as appropriate)
before removing them so all background typing tasks are explicitly terminated —
refer to the start_typing method, typing_tasks field and the shutdown function
to locate the changes.

659-669: ⚠️ Potential issue | 🟠 Major

Post creation after file upload lacks HTTP status handling.

Both paths send POST /posts and ignore non-success responses, which can silently drop file messages.

#!/bin/bash
# Verify /posts calls after file upload without explicit status checks
rg -n -C6 'failed to create post with file|post\(self\.api_url\("/posts"\)\)' src/messaging/mattermost.rs

Also applies to: 818-829

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 659 - 669, The POST to /posts after
file upload (the request built with
self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...).send().await.context("failed
to create post with file")?) does not check HTTP status and can silently ignore
non-2xx responses; update the call to inspect the Response returned by send()
(e.g., assign to a variable), call response.error_for_status() or check
status().is_success(), and return a contextual error (including status and body
text) if not successful. Apply the same fix for the other /posts call that uses
the "failed to create post" context (the second occurrence around the other
upload path) so both paths surface non-success HTTP responses.

762-764: ⚠️ Potential issue | 🟡 Minor

Don’t silently discard shutdown send errors.

Line 763 uses let _ = on a Result; handle/log it (or use .ok() if intentionally drop-safe for channel send).

As per coding guidelines: "Don't silently discard errors. No let _ = on Results. Handle them, log them, or propagate them. The only exception is .ok() on channel sends where the receiver may be dropped."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 762 - 764, The code currently
discards the Result from sending the shutdown signal with "let _ =
tx.send(()).await" on the shutdown_tx channel; change this to explicitly handle
the Result from tx.send(()).await (e.g., use if let Err(err) = tx.send(()).await
{ log the error with tracing::warn! or tracing::error! } or, if it's
intentionally safe to ignore a dropped receiver, call .ok() on the Result) so
you no longer silently discard errors; locate the send call on the shutdown_tx
(the shutdown_tx.write().await.take() -> tx.send(()).await expression) and
replace the "let _ =" pattern with explicit logging or .ok() as appropriate.

583-587: ⚠️ Potential issue | 🟡 Minor

Fail fast when bot_user_id is uninitialized for reactions.

Defaulting to an empty user ID produces opaque server-side reaction errors.

💡 Proposed fix
-                let bot_user_id = self
-                    .bot_user_id
-                    .get()
-                    .map(|s| s.as_ref().to_string())
-                    .unwrap_or_default();
+                let bot_user_id = self
+                    .bot_user_id
+                    .get()
+                    .ok_or_else(|| anyhow::anyhow!("bot_user_id not initialized"))?
+                    .as_ref()
+                    .to_string();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 583 - 587, The code currently
defaults bot_user_id to an empty string which causes opaque server errors when
sending reactions; replace the unwrap_or_default usage on
self.bot_user_id.get().map(|s| s.as_ref().to_string()).unwrap_or_default() with
an explicit failure path: either call expect/panic with a clear message like
"bot_user_id not initialized for reactions" or, preferably, change the
surrounding handler to return a Result and use ok_or_else / map_err to return a
descriptive error (e.g., "missing bot_user_id") so callers can handle it instead
of sending an empty ID; update any call sites to handle the Result if you choose
propagation.

308-317: ⚠️ Potential issue | 🟠 Major

Check /users/me HTTP status before JSON parsing.

On auth/config failures, this currently reports a parse error instead of surfacing status/body details.

#!/bin/bash
# Verify /users/me path currently parses JSON directly
rg -n -C8 'get\(self\.api_url\("/users/me"\)\)|failed to parse user response' src/messaging/mattermost.rs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 308 - 317, The current call to
self.client.get(self.api_url("/users/me")).bearer_auth(self.token.as_ref()).send().await...
then .json().await can mask non-2xx responses as JSON parse errors; modify the
flow in the /users/me request (the block that assigns to let me: MattermostUser)
to inspect the HTTP response status after send() and before json(): await the
Response from send(), check response.status().is_success() (or use
response.error_for_status()/error_for_status_ref()), and if not successful read
the response text (response.text().await) and return an error/context that
includes the status code and body; only call response.json().await when the
status is successful so parse errors truly reflect JSON problems. Ensure you
reference the same calls (self.client.get, api_url("/users/me"), bearer_auth,
send, json) when making the change.

560-567: ⚠️ Potential issue | 🟠 Major

Final stream edit can exceed Mattermost max post length.

Line 564 edits with full accumulated_text; large streams can fail finalization and leave placeholder/truncated content.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 560 - 567, The final stream edit in
the OutboundResponse::StreamEnd branch uses active.accumulated_text directly and
can exceed Mattermost's max post length; before calling
self.edit_post(&active.post_id, &active.accumulated_text).await truncate or
otherwise constrain active.accumulated_text to the Mattermost max (use a
MAX_POST_LENGTH constant or a helper like truncate_to_max_post_length) and log a
warning if truncation occurs, so edit_post receives a length-safe string and
finalization won't fail.

1142-1149: ⚠️ Potential issue | 🔴 Critical

Unsafe UTF-8 slicing can panic on non-ASCII input.

Line 1142 slices with [..max_len] before finding a char boundary.

#!/bin/bash
# Verify split_message uses direct byte slicing before boundary correction
rg -n -C4 'let search_region = &remaining\[\.\.max_len\];|floor_char_boundary' src/messaging/mattermost.rs
💡 Proposed fix
-        let search_region = &remaining[..max_len];
+        let search_end = remaining.floor_char_boundary(max_len);
+        let search_region = &remaining[..search_end];
         let break_point = search_region
             .rfind('\n')
             .or_else(|| search_region.rfind(' '))
-            .unwrap_or(max_len);
+            .unwrap_or(search_end);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 1142 - 1149, The code currently
does unsafe byte slicing with let search_region = &remaining[..max_len]; which
can panic on UTF-8 boundaries; change it to compute a safe byte index for the
desired maximum by using
remaining.char_indices().nth(max_len).map(|(i,_)|i).unwrap_or(remaining.len())
(or similar) and then set search_region = &remaining[..safe_index]; keep using
rfind to find break_point and pass that byte index into floor_char_boundary
before slicing and pushing into chunks (update the logic around search_region,
break_point, floor_char_boundary, and chunks.push to use the computed safe byte
index rather than direct ..max_len slicing).
🧹 Nitpick comments (1)
src/messaging/mattermost.rs (1)

538-553: Avoid holding active_messages write lock across awaited network I/O.

Line 552 awaits edit_post while the write lock is held, which can stall other streams and status updates.

💡 Proposed refactor
             OutboundResponse::StreamChunk(chunk) => {
-                let mut active_messages = self.active_messages.write().await;
-                if let Some(active) = active_messages.get_mut(&message.id) {
+                let mut update: Option<(Arc<str>, String)> = None;
+                {
+                    let mut active_messages = self.active_messages.write().await;
+                    if let Some(active) = active_messages.get_mut(&message.id) {
                         active.accumulated_text.push_str(&chunk);
@@
-                        if let Err(error) = self.edit_post(&active.post_id, &display_text).await {
-                            tracing::warn!(%error, "failed to edit streaming message");
-                        }
+                        update = Some((active.post_id.clone(), display_text));
                         active.last_edit = Instant::now();
                     }
                 }
+                if let Some((post_id, display_text)) = update
+                    && let Err(error) = self.edit_post(&post_id, &display_text).await
+                {
+                    tracing::warn!(%error, "failed to edit streaming message");
+                }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 538 - 553, The code currently holds
the active_messages write lock while awaiting self.edit_post, which can block
other tasks; fix by extracting/cloning the needed data (e.g., clone
active.post_id and active.accumulated_text or compute display_text) while
holding the lock, then drop the lock before calling self.edit_post(&post_id,
&display_text). After the await, reacquire the write lock to update
active.last_edit (and any other state) only if the edit succeeded; use the same
symbols active_messages, active.accumulated_text, active.post_id, edit_post,
STREAM_EDIT_THROTTLE, and MAX_MESSAGE_LENGTH to locate and implement the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/messaging/mattermost.rs`:
- Around line 1067-1079: The identity lookup swallows network/JSON errors via
multiple .ok()? calls (e.g., the cache.read().await.get(user_id) path,
client.get(url).bearer_auth(token).send().await.ok()?, and
resp.json().await.ok()? when decoding MattermostUser and later in the similar
block at 1091-1103); replace those .ok()? chains with explicit error handling:
check resp.error_for_status() (or inspect resp.status()) and handle send() and
json() Result errors with matches, logging the error context (include user_id
and URL) or propagating a proper Result type instead of silently returning None,
and ensure the parsed MattermostUser (and its display_name()) is only used after
successful JSON deserialization.

---

Outside diff comments:
In `@src/config/types.rs`:
- Around line 1651-1659: The match on message.source (which sets mention_key) is
missing a branch for Mattermost, causing require_mention checks to drop
Mattermost messages; update the match in the code that defines mention_key (the
match on message.source.as_str()) to include a "mattermost" arm that maps to
"mattermost_mentions_or_replies_to_bot" so Mattermost inbound metadata is
recognized the same as other platforms.
- Around line 1543-1576: The generic channel filtering block using
self.channel_ids and the computed variables message_channel, slack_channel, and
twitch_channel is preventing Mattermost checks from running; update the channel
extraction and matching logic to also handle Mattermost by reading
message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str()) (and
include a parent mattermost id if applicable) and include that value in the
direct_match boolean (similar to slack_channel/twitch_channel checks), and
ensure the same adjustment is applied to the later Mattermost-specific branch
referenced around the parent/channel checks (the code that computes
message_channel, parent_channel, direct_match, and parent_match).

---

Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 518-523: OutboundResponse::StreamStart currently extracts only
message.metadata["mattermost_root_id"] and calls start_typing without the same
thread fallback used elsewhere; update the StreamStart branch to follow the
text-path logic by checking message.metadata for "mattermost_root_id" and, if
absent, falling back to REPLY_TO_MESSAGE_ID before using that value for threaded
behavior (the same metadata lookup pattern used in the text response branch),
ensuring start_typing/channel handling uses the resolved thread id from
message.metadata.
- Around line 154-186: The start_typing function currently inserts a new
tokio::task::JoinHandle into typing_tasks without stopping any existing task for
the same channel; update start_typing to check typing_tasks (by channel_id) for
an existing handle, call abort() on it and optionally await its completion or
ignore the result before inserting the new handle so you don't leak duplicate
tasks; likewise modify the shutdown logic (the code that currently clears
typing_tasks) to instead iterate all stored JoinHandles, call abort() on each
handle (and await or detach as appropriate) before removing them so all
background typing tasks are explicitly terminated — refer to the start_typing
method, typing_tasks field and the shutdown function to locate the changes.
- Around line 659-669: The POST to /posts after file upload (the request built
with
self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...).send().await.context("failed
to create post with file")?) does not check HTTP status and can silently ignore
non-2xx responses; update the call to inspect the Response returned by send()
(e.g., assign to a variable), call response.error_for_status() or check
status().is_success(), and return a contextual error (including status and body
text) if not successful. Apply the same fix for the other /posts call that uses
the "failed to create post" context (the second occurrence around the other
upload path) so both paths surface non-success HTTP responses.
- Around line 762-764: The code currently discards the Result from sending the
shutdown signal with "let _ = tx.send(()).await" on the shutdown_tx channel;
change this to explicitly handle the Result from tx.send(()).await (e.g., use if
let Err(err) = tx.send(()).await { log the error with tracing::warn! or
tracing::error! } or, if it's intentionally safe to ignore a dropped receiver,
call .ok() on the Result) so you no longer silently discard errors; locate the
send call on the shutdown_tx (the shutdown_tx.write().await.take() ->
tx.send(()).await expression) and replace the "let _ =" pattern with explicit
logging or .ok() as appropriate.
- Around line 583-587: The code currently defaults bot_user_id to an empty
string which causes opaque server errors when sending reactions; replace the
unwrap_or_default usage on self.bot_user_id.get().map(|s|
s.as_ref().to_string()).unwrap_or_default() with an explicit failure path:
either call expect/panic with a clear message like "bot_user_id not initialized
for reactions" or, preferably, change the surrounding handler to return a Result
and use ok_or_else / map_err to return a descriptive error (e.g., "missing
bot_user_id") so callers can handle it instead of sending an empty ID; update
any call sites to handle the Result if you choose propagation.
- Around line 308-317: The current call to
self.client.get(self.api_url("/users/me")).bearer_auth(self.token.as_ref()).send().await...
then .json().await can mask non-2xx responses as JSON parse errors; modify the
flow in the /users/me request (the block that assigns to let me: MattermostUser)
to inspect the HTTP response status after send() and before json(): await the
Response from send(), check response.status().is_success() (or use
response.error_for_status()/error_for_status_ref()), and if not successful read
the response text (response.text().await) and return an error/context that
includes the status code and body; only call response.json().await when the
status is successful so parse errors truly reflect JSON problems. Ensure you
reference the same calls (self.client.get, api_url("/users/me"), bearer_auth,
send, json) when making the change.
- Around line 560-567: The final stream edit in the OutboundResponse::StreamEnd
branch uses active.accumulated_text directly and can exceed Mattermost's max
post length; before calling self.edit_post(&active.post_id,
&active.accumulated_text).await truncate or otherwise constrain
active.accumulated_text to the Mattermost max (use a MAX_POST_LENGTH constant or
a helper like truncate_to_max_post_length) and log a warning if truncation
occurs, so edit_post receives a length-safe string and finalization won't fail.
- Around line 1142-1149: The code currently does unsafe byte slicing with let
search_region = &remaining[..max_len]; which can panic on UTF-8 boundaries;
change it to compute a safe byte index for the desired maximum by using
remaining.char_indices().nth(max_len).map(|(i,_)|i).unwrap_or(remaining.len())
(or similar) and then set search_region = &remaining[..safe_index]; keep using
rfind to find break_point and pass that byte index into floor_char_boundary
before slicing and pushing into chunks (update the logic around search_region,
break_point, floor_char_boundary, and chunks.push to use the computed safe byte
index rather than direct ..max_len slicing).

---

Nitpick comments:
In `@src/messaging/mattermost.rs`:
- Around line 538-553: The code currently holds the active_messages write lock
while awaiting self.edit_post, which can block other tasks; fix by
extracting/cloning the needed data (e.g., clone active.post_id and
active.accumulated_text or compute display_text) while holding the lock, then
drop the lock before calling self.edit_post(&post_id, &display_text). After the
await, reacquire the write lock to update active.last_edit (and any other state)
only if the edit succeeded; use the same symbols active_messages,
active.accumulated_text, active.post_id, edit_post, STREAM_EDIT_THROTTLE, and
MAX_MESSAGE_LENGTH to locate and implement the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ca599496-9b51-4c85-bc78-65074e5fafde

📥 Commits

Reviewing files that changed from the base of the PR and between b069b56 and daca014.

📒 Files selected for processing (2)
  • src/config/types.rs
  • src/messaging/mattermost.rs

mattermost.rs:
- Validate base_url has no path/query/fragment in new()
- Check /users/me HTTP status before .json() to surface auth errors
- start_typing() aborts previous task before spawning to prevent leaks
- shutdown() drains typing_tasks with abort() instead of clear()
- StreamStart now falls back to REPLY_TO_MESSAGE_ID for thread replies
- StreamEnd applies MAX_MESSAGE_LENGTH chunking to avoid oversized edits
- Reaction handler fails fast if bot_user_id not initialized
- respond() file attachment post checks HTTP status
- broadcast() file upload checks HTTP status
- broadcast() resolves dm:{user_id} targets via /channels/direct API
- split_message uses floor_char_boundary before slicing to prevent UTF-8 panics
- Identity/channel lookups log errors via tracing::debug instead of silent None

target.rs:
- resolve_broadcast_target extracts named adapter key from conversation ID
- normalize_mattermost_target strips team/instance prefixes down to bare channel_id or dm:{user_id}

api/messaging.rs:
- Validate mattermost base_url before persisting to config.toml
- Delete default Mattermost instance now clears all credential fields

config/load.rs:
- Named Mattermost instances with missing credentials are preserved but
  disabled (map + enabled&&has_credentials) instead of silently dropped
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/messaging/mattermost.rs (2)

840-842: ⚠️ Potential issue | 🟡 Minor

Don’t silently discard shutdown signal errors.

let _ = tx.send(()).await hides failure to notify the websocket task.

As per coding guidelines: "Don't silently discard errors. No let _ = on Results. Handle them, log them, or propagate them. The only exception is .ok() on channel sends where the receiver may be dropped"

Suggested fix
         if let Some(tx) = self.shutdown_tx.write().await.take() {
-            let _ = tx.send(()).await;
+            if let Err(error) = tx.send(()).await {
+                tracing::debug!(%error, adapter = %self.runtime_key, "shutdown receiver already closed");
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 840 - 842, The shutdown send
currently discards errors with `let _ = tx.send(()).await;`; update the code
around `self.shutdown_tx.write().await.take()` so the Result from
`tx.send(()).await` is not silently ignored—handle the error by at minimum
logging it (e.g., via the module logger or `error!`) or propagating it upward;
ensure the failure to notify the websocket task is visible (include context like
"failed to send shutdown signal to websocket task") and keep the existing
`take()` behavior on `shutdown_tx`.

916-927: ⚠️ Potential issue | 🟠 Major

broadcast() file uploads don’t validate /posts HTTP status.

This path can silently report success even when Mattermost rejects the post (401/403/413/etc).

Suggested fix
-                self.client
+                let post_response = self.client
                     .post(self.api_url("/posts"))
                     .bearer_auth(self.token.as_ref())
                     .json(&serde_json::json!({
                         "channel_id": target,
                         "message": caption.unwrap_or_default(),
                         "file_ids": file_ids,
                     }))
                     .send()
                     .await
                     .context("failed to create post with file")?;
+                let post_status = post_response.status();
+                if !post_status.is_success() {
+                    let body = post_response.text().await.unwrap_or_default();
+                    return Err(anyhow::anyhow!(
+                        "mattermost POST /posts (broadcast file) failed with status {}: {body}",
+                        post_status.as_u16()
+                    ).into());
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 916 - 927, The POST to /posts in
broadcast() currently awaits send() but ignores the HTTP status; change it to
capture the Response from
self.client.post(self.api_url("/posts")).bearer_auth(...).json(...).send().await,
then validate the status (e.g., use resp.error_for_status() or check
resp.status().is_success()), and on non-success return a contextual error that
includes the HTTP status and response body/text to surface Mattermost errors
(401/403/413/etc.); update the error message passed to .context("failed to
create post with file") to include these details.
🧹 Nitpick comments (2)
src/api/messaging.rs (2)

518-570: Rename mm to a descriptive variable name.

Please avoid abbreviated local names here (e.g., mattermost_config) for readability and consistency with repo style.

As per coding guidelines **/*.rs: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch. Common abbreviations like config are fine".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/messaging.rs` around lines 518 - 570, The local variable `mm` in the
Mattermost block is an unclear abbreviation; rename it to a descriptive
identifier like `mattermost_config` (or similar) wherever it's used in this
block (the binding from doc.get("messaging").and_then(|m| m.get("mattermost")),
the checks for base_url/token/enabled, the `named_instances` extraction, and the
loop that computes `instance_name`, `instance_enabled`, and
`instance_configured`) so the code remains readable and consistent; update all
references passed into functions such as push_instance_status and calls to
normalize_adapter_selector and ensure no other symbol names change.

1331-1347: Deduplicate Mattermost base URL validation.

The same validation block appears in both default and named instance paths. Extracting a helper will reduce drift risk.

♻️ Proposed refactor
+fn is_valid_mattermost_origin(base_url: &str) -> bool {
+    url::Url::parse(base_url)
+        .map(|parsed_url| {
+            parsed_url.path() == "/"
+                && parsed_url.query().is_none()
+                && parsed_url.fragment().is_none()
+        })
+        .unwrap_or(false)
+}
...
-                    if let Some(url) = &credentials.mattermost_base_url {
-                        if url::Url::parse(url)
-                            .map(|u| u.path() != "/" || u.query().is_some() || u.fragment().is_some())
-                            .unwrap_or(true)
-                        {
+                    if let Some(url) = &credentials.mattermost_base_url {
+                        if !is_valid_mattermost_origin(url) {
                             return Ok(Json(MessagingInstanceActionResponse {
                                 success: false,
                                 message: format!("invalid mattermost base_url: must be an origin URL (e.g. https://mm.example.com)"),
                             }));
                         }
                         platform_table["base_url"] = toml_edit::value(url.as_str());
                     }
...
-                    if let Some(url) = &credentials.mattermost_base_url {
-                        if url::Url::parse(url)
-                            .map(|u| u.path() != "/" || u.query().is_some() || u.fragment().is_some())
-                            .unwrap_or(true)
-                        {
+                    if let Some(url) = &credentials.mattermost_base_url {
+                        if !is_valid_mattermost_origin(url) {
                             return Ok(Json(MessagingInstanceActionResponse {
                                 success: false,
                                 message: format!("invalid mattermost base_url: must be an origin URL (e.g. https://mm.example.com)"),
                             }));
                         }
                         instance_table["base_url"] = toml_edit::value(url.as_str());
                     }

Also applies to: 1457-1473

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/messaging.rs` around lines 1331 - 1347, Duplicate Mattermost base_url
parsing/validation occurs in the "mattermost" branch (checking
credentials.mattermost_base_url with url::Url::parse and the path/query/fragment
conditions) and should be extracted into a small helper to avoid drift; add a
function like validate_mattermost_base_url(url: &str) -> Result<(), String> (or
similar) that encapsulates the parse and the condition (path == "/" &&
query.is_none() && fragment.is_none()), call that helper from both places, and
on Err return the same Json(MessagingInstanceActionResponse { success: false,
message: ... }) behavior; update places that currently set
platform_table["base_url"] to use the validated url after the helper returns Ok.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/messaging/mattermost.rs`:
- Around line 434-442: The code currently swallows JSON decode errors for
WebSocket posts (the chain that produces post_result from
MattermostWsEvent.data.get("post") and
serde_json::from_str::<MattermostPost>()); change this to explicitly handle and
log failures instead of using .ok(): extract the "post" string, attempt
serde_json::from_str in a match or if let Err(e) pattern, log the error with
context (including the raw text or post string and the error) and only proceed
when Ok(post) yields a MattermostPost; update references to post_result to use
the validated value so malformed payloads are visible in logs rather than
silently dropped.
- Around line 620-635: When handling OutboundResponse::StreamEnd, the overflow
chunks are being posted with root_id = None which breaks the thread; update the
create_post call inside the chunks loop to pass the original thread root (e.g.
Some(active.post_id) or Some(active.root_id) if the active message stores a
separate root_id) instead of None so overflow chunks remain in the same thread
as the edited post (adjust create_post invocation and any active struct field
used to hold the thread root).

In `@src/messaging/target.rs`:
- Around line 289-292: normalize_mattermost_target currently treats a
single-segment "dm" as a valid channel_id; update the normalization to reject
bare "dm" targets by ensuring the single-segment match (the branch handling
[channel_id]) also checks channel_id != "dm" (and non-empty) and returns None
for that case, and keep the existing two-segment ["dm", user_id] branch
unchanged so only "dm:<user_id>" forms are accepted; modify the match in
normalize_mattermost_target to add that explicit check.
- Around line 268-283: The function extract_mattermost_adapter_from_channel_id
is mis-matching default DM IDs (e.g. "mattermost:{team_id}:dm:{user_id}") as
named-instance IDs; update the match arms to inspect the third part (team_id)
instead of the fourth so only true named-instance IDs like ["mattermost",
instance, team_id, channel] (where team_id != "dm") and the 5-part
named-instance DM ["mattermost", instance, team_id, "dm", user_id] map to
"mattermost:{instance}", while default 3- or 4-part IDs with the third part ==
"dm" fall through to the default "mattermost" case; modify
extract_mattermost_adapter_from_channel_id accordingly so
resolve_broadcast_target no longer routes default DMs to a non-existent named
adapter.

---

Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 840-842: The shutdown send currently discards errors with `let _ =
tx.send(()).await;`; update the code around
`self.shutdown_tx.write().await.take()` so the Result from `tx.send(()).await`
is not silently ignored—handle the error by at minimum logging it (e.g., via the
module logger or `error!`) or propagating it upward; ensure the failure to
notify the websocket task is visible (include context like "failed to send
shutdown signal to websocket task") and keep the existing `take()` behavior on
`shutdown_tx`.
- Around line 916-927: The POST to /posts in broadcast() currently awaits send()
but ignores the HTTP status; change it to capture the Response from
self.client.post(self.api_url("/posts")).bearer_auth(...).json(...).send().await,
then validate the status (e.g., use resp.error_for_status() or check
resp.status().is_success()), and on non-success return a contextual error that
includes the HTTP status and response body/text to surface Mattermost errors
(401/403/413/etc.); update the error message passed to .context("failed to
create post with file") to include these details.

---

Nitpick comments:
In `@src/api/messaging.rs`:
- Around line 518-570: The local variable `mm` in the Mattermost block is an
unclear abbreviation; rename it to a descriptive identifier like
`mattermost_config` (or similar) wherever it's used in this block (the binding
from doc.get("messaging").and_then(|m| m.get("mattermost")), the checks for
base_url/token/enabled, the `named_instances` extraction, and the loop that
computes `instance_name`, `instance_enabled`, and `instance_configured`) so the
code remains readable and consistent; update all references passed into
functions such as push_instance_status and calls to normalize_adapter_selector
and ensure no other symbol names change.
- Around line 1331-1347: Duplicate Mattermost base_url parsing/validation occurs
in the "mattermost" branch (checking credentials.mattermost_base_url with
url::Url::parse and the path/query/fragment conditions) and should be extracted
into a small helper to avoid drift; add a function like
validate_mattermost_base_url(url: &str) -> Result<(), String> (or similar) that
encapsulates the parse and the condition (path == "/" && query.is_none() &&
fragment.is_none()), call that helper from both places, and on Err return the
same Json(MessagingInstanceActionResponse { success: false, message: ... })
behavior; update places that currently set platform_table["base_url"] to use the
validated url after the helper returns Ok.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9929b306-caea-415a-ad85-11cf9986a955

📥 Commits

Reviewing files that changed from the base of the PR and between daca014 and 12ff932.

📒 Files selected for processing (4)
  • src/api/messaging.rs
  • src/config/load.rs
  • src/messaging/mattermost.rs
  • src/messaging/target.rs

channel_ids filter at line 1543 only checked Discord/Slack/Twitch metadata,
causing it to return false for Mattermost messages before the Mattermost-
specific block ran. Added mattermost_channel_id to the direct_match set.

require_mention routing had no mattermost arm in the mention_key switch,
so all Mattermost messages were dropped when require_mention = true. Added
mattermost -> mattermost_mentions_or_replies_to_bot mapping.
target.rs: Default DM conv IDs (mattermost:team:dm:user, 4 parts) were
matching the named-instance arm in extract_mattermost_adapter_from_channel_id
and returning mattermost:{team_id} as the adapter. Fixed match arm ordering
so the default-DM pattern is checked before the 4-part named-channel pattern.

target.rs: normalize_mattermost_target accepted bare 'dm' as a valid channel
ID. Added != 'dm' guard to the single-segment arm.

mattermost.rs: StreamEnd overflow chunks were posted with root_id = None,
escaping the original thread. Now resolves root_id from mattermost_root_id /
REPLY_TO_MESSAGE_ID metadata and passes it to all overflow create_post calls.

mattermost.rs: WS post JSON decode failures were silently dropped. Now logs
at DEBUG level via tracing::debug so malformed payloads are visible.
Use Cow<str> so the common case (text under MAX_MESSAGE_LENGTH) borrows
accumulated_text directly instead of cloning it on every throttled edit.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/messaging/mattermost.rs (2)

432-433: ⚠️ Potential issue | 🟡 Minor

Do not discard lifecycle Results silently.

OnceCell::set(...).ok() (Lines 432-433) and let _ = tx.send(()).await (Line 916) drop error context and make startup/shutdown state transitions harder to diagnose.

🛠️ Proposed explicit handling
-        self.bot_user_id.set(user_id.clone()).ok();
-        self.bot_username.set(username.clone()).ok();
+        if self.bot_user_id.set(user_id.clone()).is_err() {
+            tracing::debug!(adapter = %self.runtime_key, "bot_user_id already initialized");
+        }
+        if self.bot_username.set(username.clone()).is_err() {
+            tracing::debug!(adapter = %self.runtime_key, "bot_username already initialized");
+        }
@@
-        if let Some(tx) = self.shutdown_tx.write().await.take() {
-            let _ = tx.send(()).await;
-        }
+        if let Some(tx) = self.shutdown_tx.write().await.take()
+            && let Err(error) = tx.send(()).await
+        {
+            tracing::debug!(%error, adapter = %self.runtime_key, "shutdown signal receiver dropped");
+        }
As per coding guidelines "Don't silently discard errors. No `let _ =` on Results. Handle them, log them, or propagate them. The only exception is `.ok()` on channel sends where the receiver may be dropped".

Also applies to: 915-917

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 432 - 433, The two OnceCell set
calls (self.bot_user_id.set(...) and self.bot_username.set(...)) and the
tx.send(()).await are currently discarding Results via .ok() and let _ = ..., so
replace those silent drops with explicit handling: check the Result from
self.bot_user_id.set(...) and self.bot_username.set(...) (or use try_set/expect
if startup must fail) and log or return an Err with contextual messages if
setting fails, and handle tx.send(()).await by matching the Result and logging
the send error or propagating it instead of ignoring it; update places
referencing bot_user_id, bot_username, and the tx.send call to use match/if let
Err(e) { /* log e or return Err(e) */ } so lifecycle failures are surfaced.

991-1002: ⚠️ Potential issue | 🟠 Major

Check /posts status in the broadcast file path.

At Line 991, non-2xx responses from creating the post after upload are currently treated as success, so file broadcasts can fail silently.

🧪 Proposed status/body handling
-                self.client
+                let post_response = self.client
                     .post(self.api_url("/posts"))
                     .bearer_auth(self.token.as_ref())
                     .json(&serde_json::json!({
                         "channel_id": target,
                         "message": caption.unwrap_or_default(),
                         "file_ids": file_ids,
                     }))
                     .send()
                     .await
                     .context("failed to create post with file")?;
+                let post_status = post_response.status();
+                if !post_status.is_success() {
+                    let body = post_response.text().await.unwrap_or_default();
+                    return Err(anyhow::anyhow!(
+                        "mattermost POST /posts (broadcast file) failed with status {}: {body}",
+                        post_status.as_u16()
+                    ).into());
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 991 - 1002, The post-create call
currently ignores non-2xx responses and treats them as success; change the
self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...)...send().await
call to capture the Response into a variable, await and then verify
response.status().is_success(); if not successful, read response.text().await
and return an error (or context) that includes the HTTP status and response body
so failures aren't silent. Replace the existing chained
.send().await.context("failed to create post with file")? with this explicit
check around the response from the post request.
🧹 Nitpick comments (2)
src/config/types.rs (2)

1565-1576: Consolidate Mattermost channel filtering to one code path.

channel_ids for Mattermost is checked in both the generic block and a Mattermost-specific block, which duplicates logic and risks drift.

♻️ Proposed simplification
-        // Mattermost channel filter
-        if !self.channel_ids.is_empty() && self.channel == "mattermost" {
-            let message_channel = message
-                .metadata
-                .get("mattermost_channel_id")
-                .and_then(|v| v.as_str());
-            if !self.channel_ids.iter().any(|id| Some(id.as_str()) == message_channel) {
-                return false;
-            }
-        }

Also applies to: 1610-1619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1565 - 1576, The Mattermost channel ID
check is duplicated across the generic channel matching and a
Mattermost-specific block; consolidate by keeping a single check path: compute
mattermost_channel once from message.metadata (as currently done) and include it
only in the unified direct_match expression that checks message_channel,
slack_channel, twitch_channel and mattermost_channel against self.channel_ids
(using consistent to_string() conversion where needed), then remove the
redundant Mattermost-specific containment check in the other block (the similar
logic around the 1610-1619 region) so all channel filtering uses the same
variables and comparison logic.

1889-1891: Use descriptive closure variable names.

Please rename i to instance for readability and guideline alignment.

✏️ Small cleanup
-            mattermost.instances.iter().map(|i| i.name.as_str()),
+            mattermost
+                .instances
+                .iter()
+                .map(|instance| instance.name.as_str()),

As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1889 - 1891, The closure passed to
validate_instance_names uses the abbreviated variable name `i`; rename it to a
descriptive name like `instance` (e.g., change
`mattermost.instances.iter().map(|i| i.name.as_str())` to use `|instance|
instance.name.as_str()`) to improve readability and follow naming guidelines;
ensure any other occurrences in the same expression or nearby bindings are
updated to the new name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config/types.rs`:
- Around line 1916-1932: The validate_mattermost_url function currently allows
"http" and only warns; change it to fail closed: in validate_mattermost_url,
after parsing the URL examine parsed.scheme() and parsed.host_str(), and if
scheme == "http" then only allow it when the host is an explicit local/dev host
(e.g., host_str == Some("localhost") or host_str starts_with "127." or host_str
== Some("::1") or parsed.host_str().map(|h|
h.ends_with(".local")).unwrap_or(false)); otherwise return
Err(ConfigError::Invalid with a message that non-HTTPS Mattermost base_url is
not allowed except for local hosts). Ensure references to
validate_mattermost_url and parsed are used so the change is easy to locate.
- Around line 2646-2694: Add a SystemSecrets impl for MattermostConfig and
MattermostInstanceConfig that registers their token fields as secret fields so
they are included in the global secret_fields() collection; specifically
implement the SystemSecrets trait for MattermostConfig (which contains
instances: Vec<MattermostInstanceConfig>) and for MattermostInstanceConfig and
ensure the implementation registers the "token" field (and any per-instance
secret keys if your SystemSecrets API requires a fully-qualified path) so the
unified secret/env injection used elsewhere will pick up Mattermost tokens
instead of falling back to manual env handling.

In `@src/messaging/mattermost.rs`:
- Around line 1052-1060: The current logic in the message acceptance block using
permissions.channel_filter, team_id, and post.channel_id is fail-open when a
team has no entry; change it to fail-closed by treating a missing map entry as
disallowing all channels: if permissions.channel_filter is non-empty, require
Some(tid) for team_id, then if permissions.channel_filter.get(&tid) returns
None, return None (reject); otherwise proceed to check
allowed_channels.contains(&post.channel_id) as before.
- Around line 663-681: The code is awaiting self.edit_post(...) while holding
the active_messages write lock; extract the required data (e.g., clone
display_text and post_id and maybe a copy of message.id) while holding the lock,
drop the write lock before calling self.edit_post(&post_id, &display_text).
After the await, re-acquire the write lock and verify the active entry still
exists and corresponds to the same post_id, then update active.last_edit (and
accumulated_text if needed). Use the same symbols from the diff:
active_messages, active.accumulated_text, active.post_id, active.last_edit,
STREAM_EDIT_THROTTLE, MAX_MESSAGE_LENGTH, and method self.edit_post to implement
this lock-safe refactor.

---

Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 432-433: The two OnceCell set calls (self.bot_user_id.set(...) and
self.bot_username.set(...)) and the tx.send(()).await are currently discarding
Results via .ok() and let _ = ..., so replace those silent drops with explicit
handling: check the Result from self.bot_user_id.set(...) and
self.bot_username.set(...) (or use try_set/expect if startup must fail) and log
or return an Err with contextual messages if setting fails, and handle
tx.send(()).await by matching the Result and logging the send error or
propagating it instead of ignoring it; update places referencing bot_user_id,
bot_username, and the tx.send call to use match/if let Err(e) { /* log e or
return Err(e) */ } so lifecycle failures are surfaced.
- Around line 991-1002: The post-create call currently ignores non-2xx responses
and treats them as success; change the
self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...)...send().await
call to capture the Response into a variable, await and then verify
response.status().is_success(); if not successful, read response.text().await
and return an error (or context) that includes the HTTP status and response body
so failures aren't silent. Replace the existing chained
.send().await.context("failed to create post with file")? with this explicit
check around the response from the post request.

---

Nitpick comments:
In `@src/config/types.rs`:
- Around line 1565-1576: The Mattermost channel ID check is duplicated across
the generic channel matching and a Mattermost-specific block; consolidate by
keeping a single check path: compute mattermost_channel once from
message.metadata (as currently done) and include it only in the unified
direct_match expression that checks message_channel, slack_channel,
twitch_channel and mattermost_channel against self.channel_ids (using consistent
to_string() conversion where needed), then remove the redundant
Mattermost-specific containment check in the other block (the similar logic
around the 1610-1619 region) so all channel filtering uses the same variables
and comparison logic.
- Around line 1889-1891: The closure passed to validate_instance_names uses the
abbreviated variable name `i`; rename it to a descriptive name like `instance`
(e.g., change `mattermost.instances.iter().map(|i| i.name.as_str())` to use
`|instance| instance.name.as_str()`) to improve readability and follow naming
guidelines; ensure any other occurrences in the same expression or nearby
bindings are updated to the new name.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 168aa834-375d-49d8-9b1f-0d4dbe25e9c0

📥 Commits

Reviewing files that changed from the base of the PR and between 12ff932 and b42929f.

📒 Files selected for processing (3)
  • src/config/types.rs
  • src/messaging/mattermost.rs
  • src/messaging/target.rs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/messaging/mattermost.rs (1)

995-1005: ⚠️ Potential issue | 🟠 Major

Missing HTTP status check for post creation after file upload.

The /posts response status is not checked, so 4xx/5xx errors are silently ignored. This is inconsistent with the same logic in respond() (lines 819-826) which properly checks post_status.is_success().

🐛 Proposed fix
-                self.client
+                let post_response = self
+                    .client
                     .post(self.api_url("/posts"))
                     .bearer_auth(self.token.as_ref())
                     .json(&serde_json::json!({
                         "channel_id": target,
                         "message": caption.unwrap_or_default(),
                         "file_ids": file_ids,
                     }))
                     .send()
                     .await
                     .context("failed to create post with file")?;
+                let post_status = post_response.status();
+                if !post_status.is_success() {
+                    let body = post_response.text().await.unwrap_or_default();
+                    return Err(anyhow::anyhow!(
+                        "mattermost POST /posts (file broadcast) failed with status {}: {body}",
+                        post_status.as_u16()
+                    ).into());
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 995 - 1005, The post-creation call
using self.client.post(self.api_url("/posts")) does not check the HTTP response
status so 4xx/5xx failures are ignored; change the call in the block that posts
with file_ids to capture the response (await the send into a variable), check
response.status().is_success(), and return an error/context if not successful
(similar to the existing logic in respond()), including the status and any body
text in the error message to aid debugging; locate the code around the POST to
"/posts" where .bearer_auth, .json and .send().await are used and apply the same
status-check pattern as in respond().
🧹 Nitpick comments (2)
src/messaging/mattermost.rs (2)

490-490: Consider logging malformed WebSocket event envelopes.

JSON parse failures for the outer event structure are silently dropped, which can make debugging integration issues harder. The post payload parsing (lines 499-506) already logs errors correctly.

🔧 Suggested fix
-                                            if let Ok(event) = serde_json::from_str::<MattermostWsEvent>(&text) {
+                                            let event = match serde_json::from_str::<MattermostWsEvent>(&text) {
+                                                Ok(e) => e,
+                                                Err(error) => {
+                                                    tracing::trace!(%error, "failed to parse Mattermost WS event envelope");
+                                                    continue;
+                                                }
+                                            };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` at line 490, The WebSocket message parsing
silently ignores failures when deserializing into MattermostWsEvent; update the
code around the serde_json::from_str::<MattermostWsEvent>(&text) branch to log
parse errors (including the raw text and the serde error) rather than dropping
them silently so malformed outer envelopes are visible; locate the parsing site
that uses MattermostWsEvent and add a processLogger/error (or tracing::error)
call that records the error and the offending text before continuing.

1484-1510: Consider adding a test for channel_filter with unconfigured team.

The existing tests verify fail-closed when team_id is absent, but there's no test case for when channel_filter is configured and a message comes from a team that has no entry in the map. This would help catch the fail-open behavior flagged above.

🧪 Suggested test case
#[test]
fn channel_filter_rejects_team_without_entry() {
    let p = post("user1", "chan1", None);
    let mut cf = HashMap::new();
    cf.insert("team1".into(), vec!["chan1".into()]);
    let perms = MattermostPermissions { team_filter: None, channel_filter: cf, dm_allowed_users: vec![] };
    // team2 has no entry in channel_filter → should reject (fail-closed)
    assert!(bmfp(&p, "bot", Some("team2"), &perms).is_none());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 1484 - 1510, Add a unit test that
verifies bmfp rejects messages from a team not present in
MattermostPermissions.channel_filter: create a post via
post("user1","chan1",None), build channel_filter HashMap containing only "team1"
(with "chan1"), construct MattermostPermissions with that channel_filter, then
call bmfp(&p, "bot", Some("team2"), &perms) and assert it returns None to ensure
fail-closed behavior when the team key is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/messaging/mattermost.rs`:
- Line 483: The WebSocket close send call currently swallows errors with "let _
= write.send(WsMessage::Close(None)).await;"; change this to capture the Result
from write.send(WsMessage::Close(None)).await and log any Err using the existing
logger (or connection-specific logger) rather than discarding it; locate the
call to write.send(...) and replace the ignored await with error handling that
records the failure (including the error) so Close send failures are visible in
logs.

---

Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 995-1005: The post-creation call using
self.client.post(self.api_url("/posts")) does not check the HTTP response status
so 4xx/5xx failures are ignored; change the call in the block that posts with
file_ids to capture the response (await the send into a variable), check
response.status().is_success(), and return an error/context if not successful
(similar to the existing logic in respond()), including the status and any body
text in the error message to aid debugging; locate the code around the POST to
"/posts" where .bearer_auth, .json and .send().await are used and apply the same
status-check pattern as in respond().

---

Nitpick comments:
In `@src/messaging/mattermost.rs`:
- Line 490: The WebSocket message parsing silently ignores failures when
deserializing into MattermostWsEvent; update the code around the
serde_json::from_str::<MattermostWsEvent>(&text) branch to log parse errors
(including the raw text and the serde error) rather than dropping them silently
so malformed outer envelopes are visible; locate the parsing site that uses
MattermostWsEvent and add a processLogger/error (or tracing::error) call that
records the error and the offending text before continuing.
- Around line 1484-1510: Add a unit test that verifies bmfp rejects messages
from a team not present in MattermostPermissions.channel_filter: create a post
via post("user1","chan1",None), build channel_filter HashMap containing only
"team1" (with "chan1"), construct MattermostPermissions with that
channel_filter, then call bmfp(&p, "bot", Some("team2"), &perms) and assert it
returns None to ensure fail-closed behavior when the team key is missing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: af866ad2-baa4-4bc9-bc37-37292aa5557f

📥 Commits

Reviewing files that changed from the base of the PR and between b42929f and 1fd2b2b.

📒 Files selected for processing (1)
  • src/messaging/mattermost.rs

unverbraucht and others added 2 commits March 14, 2026 22:59
- validate_mattermost_url: fail-closed for non-localhost http (was warn-only)
- MattermostConfig: add SystemSecrets impl and register in system_secret_registry
- StreamChunk: release active_messages write lock before awaiting edit_post
- channel_filter: fail-closed when team has no allowlist entry (was fail-open)
- WS shutdown: log close frame send failure instead of discarding with let _ =

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- channel_filter: add test for fail-closed behavior when team is missing from filter
- WS event loop: log outer MattermostWsEvent parse failures (was silently dropped)
- broadcast: check HTTP status on /posts with file_ids (was unchecked)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@unverbraucht
Copy link
Author

@coderabbitai resume

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 15, 2026

✅ Actions performed

Reviews resumed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/messaging/mattermost.rs (1)

432-433: ⚠️ Potential issue | 🟡 Minor

Avoid silently discarding startup/shutdown Results.

OnceCell::set(...).ok() (Line 432-433) and let _ = tx.send(()).await (Line 932) hide failure paths. Handle/log the former explicitly, and use the channel-send exception pattern (.ok()) for the latter.

🔧 Proposed fix
-        self.bot_user_id.set(user_id.clone()).ok();
-        self.bot_username.set(username.clone()).ok();
+        if self.bot_user_id.set(user_id.clone()).is_err() {
+            tracing::debug!(adapter = %self.runtime_key, "bot_user_id already initialized");
+        }
+        if self.bot_username.set(username.clone()).is_err() {
+            tracing::debug!(adapter = %self.runtime_key, "bot_username already initialized");
+        }
@@
         if let Some(tx) = self.shutdown_tx.write().await.take() {
-            let _ = tx.send(()).await;
+            tx.send(()).await.ok();
         }
#!/bin/bash
rg -n 'bot_user_id\.set\(.*\)\.ok\(\)|bot_username\.set\(.*\)\.ok\(\)|let _ = tx\.send\(\(\)\)\.await' src/messaging/mattermost.rs

Expected result after the fix: no matches.

As per coding guidelines: "Don't silently discard errors. No let _ = on Results. Handle them, log them, or propagate them. The only exception is .ok() on channel sends where the receiver may be dropped".

Also applies to: 931-933

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 432 - 433, Replace the silent
discards: do not call .ok() on OnceCell::set for self.bot_user_id and
self.bot_username; instead capture the Result returned by
self.bot_user_id.set(user_id.clone()) and
self.bot_username.set(username.clone()) and handle the Err case (e.g., log an
error or warning with the existing logger/tracing macro and include the
attempted value or reason). For the channel send use the accepted channel-send
pattern: call tx.send(()).await and explicitly ignore the Ok or call .ok() on
the awaited Result only (e.g., let _ = tx.send(()).await.ok();) so the
receiver-dropped case is intentionally silenced while other set failures are
logged/handled. Ensure you reference the exact symbols self.bot_user_id.set,
self.bot_username.set and tx.send(()).await when making the changes.
src/config/types.rs (1)

2706-2717: ⚠️ Potential issue | 🟠 Major

Named Mattermost instance secrets are not registered as system secrets.

MattermostConfig supports instances, but MATTERMOST_TOKEN is registered without an instance_pattern. That breaks parity with other adapters and leaves MATTERMOST_{INSTANCE}_TOKEN unmatched by registry-based categorization.

🔧 Proposed fix
 impl SystemSecrets for MattermostConfig {
@@
     fn secret_fields() -> &'static [SecretField] {
         &[
             SecretField {
                 toml_key: "token",
                 secret_name: "MATTERMOST_TOKEN",
-                instance_pattern: None,
+                instance_pattern: Some(InstancePattern {
+                    platform_prefix: "MATTERMOST",
+                    field_suffix: "TOKEN",
+                }),
             },
             SecretField {
                 toml_key: "base_url",
                 secret_name: "MATTERMOST_BASE_URL",
-                instance_pattern: None,
+                instance_pattern: Some(InstancePattern {
+                    platform_prefix: "MATTERMOST",
+                    field_suffix: "BASE_URL",
+                }),
             },
         ]
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 2706 - 2717, The Mattermost secret_fields
list registers MATTERMOST_TOKEN and MATTERMOST_BASE_URL without
instance_pattern, so per-instance secrets like MATTERMOST_{INSTANCE}_TOKEN are
not recognized; update the SecretField entries inside the secret_fields()
function for MattermostConfig (the SecretField with toml_key "token" and the one
with "base_url") to include appropriate instance_pattern values (e.g. pattern
matching "{instance}_TOKEN" and "{instance}_BASE_URL" or the project’s existing
instance placeholder format) so named Mattermost instance secrets are registered
like the other adapters.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/config/types.rs`:
- Around line 2706-2717: The Mattermost secret_fields list registers
MATTERMOST_TOKEN and MATTERMOST_BASE_URL without instance_pattern, so
per-instance secrets like MATTERMOST_{INSTANCE}_TOKEN are not recognized; update
the SecretField entries inside the secret_fields() function for MattermostConfig
(the SecretField with toml_key "token" and the one with "base_url") to include
appropriate instance_pattern values (e.g. pattern matching "{instance}_TOKEN"
and "{instance}_BASE_URL" or the project’s existing instance placeholder format)
so named Mattermost instance secrets are registered like the other adapters.

In `@src/messaging/mattermost.rs`:
- Around line 432-433: Replace the silent discards: do not call .ok() on
OnceCell::set for self.bot_user_id and self.bot_username; instead capture the
Result returned by self.bot_user_id.set(user_id.clone()) and
self.bot_username.set(username.clone()) and handle the Err case (e.g., log an
error or warning with the existing logger/tracing macro and include the
attempted value or reason). For the channel send use the accepted channel-send
pattern: call tx.send(()).await and explicitly ignore the Ok or call .ok() on
the awaited Result only (e.g., let _ = tx.send(()).await.ok();) so the
receiver-dropped case is intentionally silenced while other set failures are
logged/handled. Ensure you reference the exact symbols self.bot_user_id.set,
self.bot_username.set and tx.send(()).await when making the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c0c6d1a4-f4d4-4455-a752-c2b661503d3c

📥 Commits

Reviewing files that changed from the base of the PR and between b42929f and 0d98cfb.

📒 Files selected for processing (3)
  • src/config/types.rs
  • src/messaging/mattermost.rs
  • src/secrets/store.rs

- SystemSecrets: add InstancePattern to token and base_url fields so
  MATTERMOST_{INSTANCE}_TOKEN/BASE_URL are recognized as system secrets
- start(): log warning when bot_user_id/bot_username OnceCell is already set
  instead of silently discarding the error
- shutdown(): use tx.send().await.ok() pattern for intentional channel-send discard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/config/types.rs (1)

1543-1583: Deduplicate Mattermost channel_ids filtering in matches_route.

At Line 1576, Mattermost channel matching is already part of direct_match; the second Mattermost-only check at Line 1611 repeats the same constraint. Keeping one check reduces drift risk.

♻️ Simplify duplicated logic
-        // Mattermost channel filter
-        if !self.channel_ids.is_empty() && self.channel == "mattermost" {
-            let message_channel = message
-                .metadata
-                .get("mattermost_channel_id")
-                .and_then(|v| v.as_str());
-            if !self.channel_ids.iter().any(|id| Some(id.as_str()) == message_channel) {
-                return false;
-            }
-        }

Also applies to: 1611-1619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1543 - 1583, The matches_route logic
duplicates the Mattermost channel check: remove the second Mattermost-only
filter block that separately checks mattermost_channel against self.channel_ids
since mattermost_channel is already included in direct_match (the variables
mattermost_channel, direct_match, parent_match, and self.channel_ids are in this
function); ensure you keep the parent_channel parent_match logic unchanged and
rely on direct_match to cover Slack/Twitch/Mattermost direct matches to avoid
redundant checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config/types.rs`:
- Around line 1916-1947: The validate_mattermost_url function currently only
checks scheme/host; update it to reject URLs that include embedded credentials
or extra components by asserting parsed.username().is_empty(),
parsed.password().is_none(), parsed.query().is_none(),
parsed.fragment().is_none(), and that parsed.path() is either empty or "/"
(reject any other path); if any of these checks fail return a
ConfigError::Invalid with a clear message. Add these checks after parsing and
before the scheme match, referencing validate_mattermost_url and the parsed.url
methods (parsed.username(), parsed.password(), parsed.path(), parsed.query(),
parsed.fragment()) so the config only accepts origin-only https/http URLs
without credentials or extra components.

In `@src/messaging/mattermost.rs`:
- Around line 79-80: The code currently reads event.broadcast.team_id directly
and ignores the documented fallback; change the WS event handling to use the
constructor-provided default_team_id when event.broadcast.team_id is None.
Specifically, where you currently access event.broadcast.team_id (used in the
event processing/dispatch path), replace that direct access with something like:
let team_id = event.broadcast.team_id.clone().or(self.default_team_id.clone());
and then use team_id for routing/permission checks so the default_team_id is
applied whenever the event omits a team_id.

---

Nitpick comments:
In `@src/config/types.rs`:
- Around line 1543-1583: The matches_route logic duplicates the Mattermost
channel check: remove the second Mattermost-only filter block that separately
checks mattermost_channel against self.channel_ids since mattermost_channel is
already included in direct_match (the variables mattermost_channel,
direct_match, parent_match, and self.channel_ids are in this function); ensure
you keep the parent_channel parent_match logic unchanged and rely on
direct_match to cover Slack/Twitch/Mattermost direct matches to avoid redundant
checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a67aac1-db48-41ff-bc03-59c417b8fd15

📥 Commits

Reviewing files that changed from the base of the PR and between 0d98cfb and 5641416.

📒 Files selected for processing (2)
  • src/config/types.rs
  • src/messaging/mattermost.rs

- validate_mattermost_url: reject URLs with credentials, non-root path,
  query string, or fragment (accept origin-only URLs only)
- start()/WS task: apply default_team_id fallback when WS event omits team_id
- matches_route: remove duplicate Mattermost-only channel_ids filter block
  (mattermost_channel_id is already covered by direct_match)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/config/types.rs (1)

1880-1880: Use a descriptive closure variable name instead of i.

At Line 1880, map(|i| i.name.as_str()) should use a descriptive name for readability and guideline compliance.

✏️ Suggested tweak
-            mattermost.instances.iter().map(|i| i.name.as_str()),
+            mattermost.instances.iter().map(|instance| instance.name.as_str()),

As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` at line 1880, The closure in the iterator mapping uses a
non-descriptive variable name `i`; change the closure in
mattermost.instances.iter().map(|i| i.name.as_str()) to use a clear name like
`instance` (e.g., map(|instance| instance.name.as_str())) to improve readability
and follow naming guidelines while keeping behavior identical.
src/messaging/mattermost.rs (1)

1455-1461: Rename abbreviated test helpers for clarity and guideline compliance.

bmfp, bmfp_named, and single-letter params (p) are harder to scan in this already dense test module.

As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch. Common abbreviations like config are fine".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 1455 - 1461, Rename the abbreviated
test helpers and single-letter parameters for clarity: change functions bmfp and
bmfp_named to descriptive names (e.g., build_message_from_mattermost_post and
build_message_from_mattermost_post_named) and rename the parameter p to post
(and any other single-letter params) in their signatures and all local call
sites; update the calls to build_message_from_post inside these helpers to use
the new parameter names and ensure bot_id, bot_username, team_id, perms,
display_name, and channel_name arguments remain unchanged so behavior is
preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config/types.rs`:
- Line 1476: Binding.team_id was added to the config model but the API layer's
BindingResponse type (and its conversions) still omits it; update the bindings
API so clients can read/write team scoping by adding team_id to the
BindingResponse struct (and any request/patch DTOs if present), include it in
the serde/Serialize/Deserialize derives, and ensure the model↔response mapping
code (the conversion/from implementations used to build BindingResponse) copies
Binding.team_id into the response and vice versa for incoming updates.
- Around line 2759-2794: Add unit tests to mattermost_url_tests that exercise
the new rejection branches in validate_mattermost_url: create tests that assert
Err for URLs containing userinfo/credentials (e.g.,
"https://user:pass@mattermost.example.com"), for URLs with non-empty paths
beyond the root (e.g., "https://mattermost.example.com/some/path"), for URLs
containing query strings (e.g., "https://mattermost.example.com/?token=abc") and
for URLs with fragments (e.g., "https://mattermost.example.com/#section"); keep
existing ok assertions for plain https and localhost http and use
assert!(validate_mattermost_url(...).is_err()) or .is_ok() as appropriate to
verify each rejection rule in validate_mattermost_url.

In `@src/messaging/mattermost.rs`:
- Around line 826-833: The JSON payload sent in the post request to
self.client.post(self.api_url("/posts")) is missing root_id, so file replies can
escape the original thread; include the thread root id (e.g., root_id or
thread_root_id used elsewhere in this flow) in the payload alongside channel_id,
message (caption) and file_ids by adding "root_id": root_id (or the appropriate
variable holding the original post's root id) to the serde_json::json! call to
ensure file responses stay in the same thread as text/stream replies.
- Around line 650-664: In the OutboundResponse::StreamStart branch, ensure the
typing loop started by start_typing(channel_id).await is stopped if
create_post(channel_id, "\u{200B}", root_id).await returns an error; change the
code to call self.stop_typing(channel_id).await (or the equivalent typing-stop
helper) before returning the Err so the typing task is not leaked — e.g.,
perform create_post in a match or map_err that first awaits
self.stop_typing(channel_id) and then propagates the original error; update
references to start_typing, create_post, and the error return path around the
active_messages insertion accordingly.

---

Nitpick comments:
In `@src/config/types.rs`:
- Line 1880: The closure in the iterator mapping uses a non-descriptive variable
name `i`; change the closure in mattermost.instances.iter().map(|i|
i.name.as_str()) to use a clear name like `instance` (e.g., map(|instance|
instance.name.as_str())) to improve readability and follow naming guidelines
while keeping behavior identical.

In `@src/messaging/mattermost.rs`:
- Around line 1455-1461: Rename the abbreviated test helpers and single-letter
parameters for clarity: change functions bmfp and bmfp_named to descriptive
names (e.g., build_message_from_mattermost_post and
build_message_from_mattermost_post_named) and rename the parameter p to post
(and any other single-letter params) in their signatures and all local call
sites; update the calls to build_message_from_post inside these helpers to use
the new parameter names and ensure bot_id, bot_username, team_id, perms,
display_name, and channel_name arguments remain unchanged so behavior is
preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1f61c590-be6c-4550-b641-5074bd25a9c6

📥 Commits

Reviewing files that changed from the base of the PR and between 5641416 and 4403e47.

📒 Files selected for processing (2)
  • src/config/types.rs
  • src/messaging/mattermost.rs

- bindings API: add team_id to BindingResponse, CreateBindingRequest, and
  UpdateBindingRequest so Mattermost team scoping is readable/writable via API
- validate_mattermost_url: reject URLs with credentials, non-root path,
  query string, or fragment; add tests covering all new rejection branches
- broadcast File: include root_id in /posts payload so file replies stay in thread
- StreamStart: stop typing task before propagating create_post error to prevent leak
- config/types.rs: rename |i| closure var to |instance| for readability
- mattermost tests: rename bmfp/bmfp_named helpers and p param to descriptive names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace implementation plan with concise feature list, setup guide,
named instance configuration, and notes on what is missing compared
to Slack/Discord.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/bindings.rs (1)

141-156: ⚠️ Potential issue | 🟠 Major

Carry team_id through the delete flow too.

team_id is now part of the binding identity for create/list/update, but DeleteBindingRequest and delete_binding() still ignore it. Two Mattermost bindings that only differ by team become indistinguishable here, so the delete API can remove the wrong row.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/bindings.rs` around lines 141 - 156, DeleteBindingRequest and
delete_binding() currently ignore team_id, so deletes can remove the wrong
binding when multiple bindings differ only by team_id; add team_id:
Option<String> (with #[serde(default)]) to the DeleteBindingRequest struct,
propagate that field into delete_binding() and update the DB delete logic to
include team_id in the WHERE clause (e.g., add "AND team_id = $N" when
team_id.is_some(), or bind NULL/IS NULL semantics as used elsewhere), and update
any callsites/tests to supply/expect team_id-aware deletion. Ensure parameter
names match existing symbols (DeleteBindingRequest, delete_binding) so the
binding identity includes team_id for delete operations.
♻️ Duplicate comments (1)
src/messaging/mattermost.rs (1)

1177-1183: ⚠️ Potential issue | 🟠 Major

Set the directed-message flag for replies and DMs, not just @mentions.

passes_require_mention() uses mattermost_mentions_or_replies_to_bot as the adapter's allow signal. This only checks for an @{bot_username} substring, so thread replies to the bot—and DMs when a binding has require_mention = true—still get dropped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 1177 - 1183,
passes_require_mention() relies on the metadata key
"mattermost_mentions_or_replies_to_bot", but the current mentions_bot
calculation only checks post.message.contains(format!("@{bot_username}"));
update the logic around mentions_bot (the let mentions_bot = ... and subsequent
metadata.insert) to also set true when the message is a direct message (check
post.channel_type or equivalent DM indicator) and when it is a thread reply to a
post authored by the bot (check post.root_id, fetch the parent post and compare
parent.user_id to bot_user_id); keep the original `@mention` test as well and then
insert the combined boolean into the same metadata key so replies and DMs are
treated as directed messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/bindings.rs`:
- Around line 444-446: The create path currently persists Some("") for team_id
while update_binding() treats Some("") as absent; change the create logic in
bindings.rs so that when handling request.team_id you only set
binding_table["team_id"] if the string is non-empty (e.g., check
!team_id.trim().is_empty()), otherwise treat it as absent and do not write the
key—this normalizes empty team_id to the same behavior as update_binding().

In `@src/messaging/mattermost.rs`:
- Around line 1431-1438: The split logic can push an empty string when
search_region.rfind returns 0; update the loop that computes break_point (using
search_region, search_end, remaining, chunks) to guard against break_point == 0
by trimming leading whitespace/newlines from remaining and recomputing the
search region (or falling back to using search_end) before slicing and pushing;
ensure you do not push an empty chunk (i.e., only push
remaining[..end].to_string() when end > 0) and preserve the existing use of
floor_char_boundary when computing end.

---

Outside diff comments:
In `@src/api/bindings.rs`:
- Around line 141-156: DeleteBindingRequest and delete_binding() currently
ignore team_id, so deletes can remove the wrong binding when multiple bindings
differ only by team_id; add team_id: Option<String> (with #[serde(default)]) to
the DeleteBindingRequest struct, propagate that field into delete_binding() and
update the DB delete logic to include team_id in the WHERE clause (e.g., add
"AND team_id = $N" when team_id.is_some(), or bind NULL/IS NULL semantics as
used elsewhere), and update any callsites/tests to supply/expect team_id-aware
deletion. Ensure parameter names match existing symbols (DeleteBindingRequest,
delete_binding) so the binding identity includes team_id for delete operations.

---

Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 1177-1183: passes_require_mention() relies on the metadata key
"mattermost_mentions_or_replies_to_bot", but the current mentions_bot
calculation only checks post.message.contains(format!("@{bot_username}"));
update the logic around mentions_bot (the let mentions_bot = ... and subsequent
metadata.insert) to also set true when the message is a direct message (check
post.channel_type or equivalent DM indicator) and when it is a thread reply to a
post authored by the bot (check post.root_id, fetch the parent post and compare
parent.user_id to bot_user_id); keep the original `@mention` test as well and then
insert the combined boolean into the same metadata key so replies and DMs are
treated as directed messages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b14df601-66dd-4577-b7be-0fb451c84b5d

📥 Commits

Reviewing files that changed from the base of the PR and between 4403e47 and 43aa84c.

📒 Files selected for processing (3)
  • src/api/bindings.rs
  • src/config/types.rs
  • src/messaging/mattermost.rs

- create_binding: guard empty team_id to match update_binding behavior
- DeleteBindingRequest: add team_id to prevent wrong-binding deletion
- split_message: filter break_point == 0 to prevent empty chunk push
- mentions_bot: set true for DMs; detect thread replies to bot via
  async root-post fetch in WS handler (resolve_root_post_author)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/messaging/mattermost.rs (1)

132-144: Consider rejecting unsupported schemes instead of passing through.

The other => other fallback at line 137 silently passes through unknown schemes, which would produce a broken WebSocket URL. Given that the constructor validates the URL structure, this is unlikely to be reached, but failing explicitly would be clearer.

💡 Suggested defensive fix
         url.set_scheme(match self.base_url.scheme() {
             "https" => "wss",
             "http" => "ws",
-            other => other,
+            other => {
+                tracing::warn!(scheme = other, "unsupported base_url scheme for websocket");
+                other
+            }
         })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 132 - 144, The ws_url method
currently silently passes through unknown schemes via the match fallback (other
=> other) which can produce invalid websocket URLs; update ws_url to explicitly
reject unsupported schemes by replacing the fallback with an explicit failure
that includes the offending scheme (e.g., panic!/expect with a message like
"unsupported URL scheme: {scheme}"), so in the match over self.base_url.scheme()
only "http" => "ws" and "https" => "wss" are accepted and any other scheme
causes a clear, immediate error; make this change inside the ws_url function
referencing self.base_url and the scheme substitution to help locate the code.
src/api/bindings.rs (1)

737-737: Replace abbreviated variable names with descriptive names (team_id not tid, guild_id not gid, workspace_id not wid, chat_id not cid).

This pattern appears across two parallel code blocks (lines 716–737 and 924–945) where abbreviated names are used in match arm bindings. Repo guidelines require descriptive local names: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/bindings.rs` at line 737, In the match arms where you bind Option
tuples (e.g. the snippet showing "Some(tid) => table"), replace abbreviated
local bindings with descriptive names—use team_id instead of tid, guild_id
instead of gid, workspace_id instead of wid, and chat_id instead of cid—so the
match arms and any subsequent uses refer to these full names; update both
parallel blocks that follow the same pattern (the block containing "Some(tid) =>
table" and the corresponding later block) to maintain consistency and
compile-time correctness by renaming the variables wherever they are referenced
in those match arms and their scopes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/bindings.rs`:
- Around line 736-742: Normalize team_id the same way everywhere: when reading
request.original_team_id and when reading/writing table.get("team_id") treat
whitespace-only strings as None and trim surrounding whitespace before
comparisons and when storing; update the code paths that set/compare team_id
(the match using request.original_team_id, the storage code that currently
allows whitespace-only team_id, and the comparison logic around
table.get("team_id")) so they all trim().is_empty() => treat as None, otherwise
use trimmed value for equality checks and persisted values.

---

Nitpick comments:
In `@src/api/bindings.rs`:
- Line 737: In the match arms where you bind Option tuples (e.g. the snippet
showing "Some(tid) => table"), replace abbreviated local bindings with
descriptive names—use team_id instead of tid, guild_id instead of gid,
workspace_id instead of wid, and chat_id instead of cid—so the match arms and
any subsequent uses refer to these full names; update both parallel blocks that
follow the same pattern (the block containing "Some(tid) => table" and the
corresponding later block) to maintain consistency and compile-time correctness
by renaming the variables wherever they are referenced in those match arms and
their scopes.

In `@src/messaging/mattermost.rs`:
- Around line 132-144: The ws_url method currently silently passes through
unknown schemes via the match fallback (other => other) which can produce
invalid websocket URLs; update ws_url to explicitly reject unsupported schemes
by replacing the fallback with an explicit failure that includes the offending
scheme (e.g., panic!/expect with a message like "unsupported URL scheme:
{scheme}"), so in the match over self.base_url.scheme() only "http" => "ws" and
"https" => "wss" are accepted and any other scheme causes a clear, immediate
error; make this change inside the ws_url function referencing self.base_url and
the scheme substitution to help locate the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 99096b8a-eb9f-4b7c-ae79-51217dfae5fa

📥 Commits

Reviewing files that changed from the base of the PR and between 43aa84c and aa8c2c7.

📒 Files selected for processing (3)
  • docs/mattermost.md
  • src/api/bindings.rs
  • src/messaging/mattermost.rs
✅ Files skipped from review due to trivial changes (1)
  • docs/mattermost.md

- bindings: normalize team_id in update/delete matching — trim whitespace
  and treat whitespace-only values as absent for consistent comparisons
- bindings: trim team_id before persisting in update_binding write path
- bindings: rename abbreviated match locals (gid/wid/cid/tid) to full names
- ws_url: replace silent scheme passthrough with unreachable! for unsupported schemes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/api/bindings.rs (1)

736-738: Rename abbreviated locals and hoist request normalization outside the loop.

req_team_id is abbreviated and recalculated on each iteration. Prefer explicit naming and compute request normalization once before the for loop.

♻️ Suggested refactor
-    for (i, table) in bindings_array.iter().enumerate() {
+    let normalized_request_team_id = request
+        .original_team_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty());
+    for (i, table) in bindings_array.iter().enumerate() {
@@
-        let req_team_id = request.original_team_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
-        let toml_team_id = table.get("team_id").and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty());
-        let matches_team = req_team_id == toml_team_id;
+        let normalized_binding_team_id = table
+            .get("team_id")
+            .and_then(|value| value.as_str())
+            .map(str::trim)
+            .filter(|value| !value.is_empty());
+        let matches_team = normalized_request_team_id == normalized_binding_team_id;

As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch."

Also applies to: 938-940

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/bindings.rs` around lines 736 - 738, The local name req_team_id is
abbreviated and is being recomputed inside the loop; rename it to
request_team_id and compute the normalized request team once before entering the
for loop by trimming and filtering request.original_team_id.as_deref(), then
inside the loop compute toml_team_id from table.get("team_id") and compare to
request_team_id (use explicit names matches_team or team_matches). Apply the
same renaming/hoisting pattern to the analogous locals at the other site
referenced (lines 938-940) so request normalization is not repeated
per-iteration.
src/messaging/mattermost.rs (2)

91-95: Minor: unnecessary .into() conversion.

Since new() returns anyhow::Result<Self>, and anyhow::anyhow!() already produces an anyhow::Error, the .into() call is a no-op here. Compare with methods returning crate::Result where the conversion is needed.

Suggested cleanup
         if base_url.path() != "/" || base_url.query().is_some() || base_url.fragment().is_some() {
-            return Err(anyhow::anyhow!(
+            anyhow::bail!(
                 "mattermost base_url must be an origin URL without path/query/fragment (got: {})",
                 base_url
-            ).into());
+            );
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 91 - 95, The error return in
Mattermost::new() is performing an unnecessary .into() on an anyhow::Error;
remove the .into() and return the anyhow::anyhow!(...) value directly (i.e., use
Err(anyhow::anyhow!(...)) ) so the return type matches anyhow::Result<Self>
without the redundant conversion.

598-618: Use error instead of e for error variables.

Per coding guidelines: "Don't abbreviate variable names."

Suggested fix
-                                        Some(Err(e)) => {
-                                            tracing::error!(adapter = %runtime_key, error = %e, "websocket error");
+                                        Some(Err(error)) => {
+                                            tracing::error!(adapter = %runtime_key, %error, "websocket error");
                                             break;
                                         }
                                         None => break,
                                         _ => {}
                                     }
                                 }
                             }
                         }

                         tracing::info!(adapter = %runtime_key, "websocket disconnected, reconnecting...");
                     }
-                    Err(e) => {
+                    Err(error) => {
                         tracing::error!(
                             adapter = %runtime_key,
-                            error = %e,
+                            %error,
                             delay_ms = retry_delay.as_millis(),
                             "websocket connection failed, retrying"
                         );
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/messaging/mattermost.rs` around lines 598 - 618, Rename the short error
binding `e` to `error` in the websocket connection handling match arms to comply
with naming guidelines: update the pattern arms `Some(Err(e))` and `Err(e)` to
`Some(Err(error))` and `Err(error)` respectively, and update all uses in the
tracing macros (e.g., `tracing::error!(adapter = %runtime_key, error = %e, ...
)` and the other tracing call that logs `error = %e`) to reference `%error`;
ensure all occurrences within the surrounding block in mattermost.rs (the
websocket read loop and retry/error logging) are changed consistently so
compilation and log semantics remain the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/api/bindings.rs`:
- Around line 736-738: The local name req_team_id is abbreviated and is being
recomputed inside the loop; rename it to request_team_id and compute the
normalized request team once before entering the for loop by trimming and
filtering request.original_team_id.as_deref(), then inside the loop compute
toml_team_id from table.get("team_id") and compare to request_team_id (use
explicit names matches_team or team_matches). Apply the same renaming/hoisting
pattern to the analogous locals at the other site referenced (lines 938-940) so
request normalization is not repeated per-iteration.

In `@src/messaging/mattermost.rs`:
- Around line 91-95: The error return in Mattermost::new() is performing an
unnecessary .into() on an anyhow::Error; remove the .into() and return the
anyhow::anyhow!(...) value directly (i.e., use Err(anyhow::anyhow!(...)) ) so
the return type matches anyhow::Result<Self> without the redundant conversion.
- Around line 598-618: Rename the short error binding `e` to `error` in the
websocket connection handling match arms to comply with naming guidelines:
update the pattern arms `Some(Err(e))` and `Err(e)` to `Some(Err(error))` and
`Err(error)` respectively, and update all uses in the tracing macros (e.g.,
`tracing::error!(adapter = %runtime_key, error = %e, ... )` and the other
tracing call that logs `error = %e`) to reference `%error`; ensure all
occurrences within the surrounding block in mattermost.rs (the websocket read
loop and retry/error logging) are changed consistently so compilation and log
semantics remain the same.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 119f75e1-9ab9-4ace-9458-5fde5b7223fa

📥 Commits

Reviewing files that changed from the base of the PR and between aa8c2c7 and 956d11f.

📒 Files selected for processing (2)
  • src/api/bindings.rs
  • src/messaging/mattermost.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant